React通用解决方案 您所在的位置:网站首页 react 业务组件 React通用解决方案

React通用解决方案

2023-09-18 11:15| 来源: 网络整理| 查看: 265

前话

业务数据赋予了原生组件对外提供服务的能力,业务组件取数有静态数据和动态数据,业务组件更多的是通过数据请求获取动态数据。

如何写好一个「高可用」的业务系统取决于是否写好了一个个业务组件。「取数」是业务组件最为常见的场景,新人写取数逻辑往往会陷入「繁冗」的误区,从而引发一系列难以维护的问题。

下面我将介绍在React项目中如何使用Hook去优化「取数」逻辑。

正文 1. 普通数据请求

业务组件通常进行普通的数据请求,简单呈现获取到的数据,逻辑如下:

初始化data业务数据和loading状态值 mounted阶段发起请求获得业务数据,并在在请求的过程中同步loading状态值 根据loading状态渲染data业务数据

示例代码如下:

import React, { useEffect, useState } from "react"; import { Spin } from "@arco-design/web-react"; const Cmpt: React.FC = () => { // 1. 初始化data业务数据和loading状态值 const [data, setData] = useState(""); const [loading, setLoading] = useState(false); // 2. mounted阶段发起请求获得业务数据,并在在请求的过程中同步loading状态值 useEffect(() => { setLoading(true); fetchData() .then(setData) .finally(() => setLoading(false)); }, []); // 3. 根据loading状态渲染data业务数据 return {data}; }; export default Cmpt;

这种方式很好维护,当然也没有任何问题,实现了业务需求。

但重复写loading和data变量挺烦的,我们可以可以将这段数据请求与状态更新逻辑抽离成一个公共的数据请求Hook。实现如下:

import { useState, useRef, useCallback, useEffect } from 'react'; import { debounce } from 'lodash'; import { useUnmounted } from './use-unmounted'; import { useQueue } from './use-queue'; export interface IBuildUseFetchOptions { /** 是否立即查询,默认值为true */ immediate?: boolean; /** 防抖间隔(毫秒),默认值为300 */ duration?: number; /** 关联属性,默认值为空数组 */ relation?: Array; /** 筛选条件,默认值为空数组 */ properties?: Array; /** 筛选条件转换钩子函数 */ getQuery?: (query: any, props: P) => any; /** 加载数据钩子函数 */ getData?: (query: any, props: P) => Promise; } /** * 数据请求Hook工厂函数 * @param options 数据请求Hook配置 * @returns */ export function buildUseFetch(options: IBuildUseFetchOptions) { const { immediate = true, duration = 200, relation = [], properties = [], getQuery = query => query, getData = () => Promise.resolve(undefined), } = options; /** * 数据请求Hook * @param props 组件Props * @param defaultQuery 默认查询条件 * @returns */ function useFetch(props: P, defaultQuery?: C) { const [inited, setInited] = useState(false); const [loading, setLoading] = useState(immediate); const [data, setData] = useState(); const [query, setQuery] = useState({ ...properties.reduce((p, c) => { if (typeof c === 'object') { p[c.key] = c.value; } else { p[c] = undefined; } return p; }, {} as any), ...(defaultQuery || {}), }); const [targetQuery, setTargetQuery] = useState(query); // 错误信息 const [error, setError] = useState(); // 缓存变量 const ref = useRef({ props, loading, query, targetQuery, data, inited, error, }); // 挂载状态 const [, runWithoutUnmounted] = useUnmounted(); // 请求队列 const [, runWithQueue] = useQueue(); // 数据请求方法 const onFetch = useCallback( debounce(_query => { const fetch = async () => { try { const _data = await getData(_query, ref.current.props); ref.current.data = _data; runWithoutUnmounted(() => setData(_data)); ref.current.error = undefined; } catch (err) { ref.current.error = err; } finally { runWithoutUnmounted(() => setError(ref.current.error)); } }; runWithQueue(fetch, () => { runWithoutUnmounted(() => setLoading(false)); }); }, duration), [] ); // 数据加载方法 const onLoad = useCallback(_query => { ref.current.loading = true; setLoading(true); // 通过筛选条件转换钩子函数获取转换后的请求条件 const newQuery = getQuery( { ..._query, }, ref.current.props ); ref.current.targetQuery = newQuery; setTargetQuery(newQuery); onFetch(newQuery); }, []); // 数据刷新方法 const onRefresh = useCallback(() => { onLoad(ref.current.query); }, []); // 组件更新时监测查询参数变更,若变更自动执行数据加载方法 useEffect(() => { if (!ref.current.inited) { return; } ref.current.query = query; onRefresh(); }, [query]); // 组件更新时监测组件Props参数变更(通过关联属性过滤),若变更自动执行数据加载方法 useEffect(() => { if (!ref.current.inited) { return; } const oldProps = ref.current.props; ref.current.props = props; if (relation.find(p => (oldProps as any)[p] !== (props as any)[p])) { onRefresh(); } }, [props]); // 组件初始化时判断是否自动执行数据加载方法 useEffect(() => { setInited(true); ref.current.inited = true; if (immediate) { onLoad(ref.current.query); } }, []); const fetchResult = { inited, loading, query, targetQuery, data, error, }; const fetchAction = { setInited, setLoading, setQuery, setTargetQuery, setData, setError, onLoad, onRefresh, }; const ret = [fetchResult, fetchAction] as const; return ret; } return useFetch; }

为演示后续的场景这里直接放完善的Hook,该源码已上传至Github中,有兴许的同学们可以康康哈。地址在这里:github.com/pwcong/fron…

我们可以用这个数据请求Hook来优化之前繁冗的代码,优化后结果如下:

import React from "react"; import { Spin } from "@arco-design/web-react"; // 通过工厂函数生成Hook函数方便复用 const useFetch = buildUseFetch({ // 数据请求逻辑 getData: fetchData, }); const Cmpt: React.FC = (props) => { // 使用数据请求Hook const [{ loading, data }] = useFetch(props); return {data}; }; export default Cmpt;

这个名为「useFetch」的Hook的能力远不至于此,下面用一系列Demo来演示怎么用它一一攻破各种业务场景。

1.1 自定义请求参数与参数转换

业务系统中最常见的一个场景,点击「查看按钮」跳转详情页面,这时候就需要通过获取「路径参数」发起「数据详情的请求」。

「useFetch」支持自定义请求参数来应对这种请求场景,示例代码如下:

import React from "react"; import { useParams } from "react-router"; const useFetch = buildUseFetch({ // 数据请求逻辑 getData: (query) => fetchData(query.id), }); const Cmpt: React.FC = (props) => { // 获取路由参数 const { id } = useParams(); // 传入请求参数 const [{ data }] = useFetch(props, { id, }); // ... 略 }; export default Cmpt;

如果请求参数需要转换,「useFetch」同样支持在「build」过程中定义「参数转换」逻辑,如下:

import React from "react"; import { useParams } from "react-router"; const useFetch = buildUseFetch({ // 请求参数转换逻辑 getQuery: ({ id }) => { return { id, timestamp: new Date().getTime(), }; }, // 数据请求逻辑 getData: ({ id, timestamp }) => fetchData(id, timestamp), }); const Cmpt: React.FC = (props) => { // 获取路由参数 const params = useParams(); // 传入请求参数 const [{ data }] = useFetch(props, params); // ... 略 }; export default Cmpt; 1.2 手动触发请求

业务场景中通常存在这么一个场景,组件「不自行发起请求」,待用户「执行动作确认请求参数」后再「发起请求」。

「useFetch」默认「立即」发起请求,但在「build」的时候支持设定「不立即」发起请求,示例代码如下:

import React from "react"; import { Button } from "@arco-design/web-react"; const useFetch = buildUseFetch({ // 不立即请求 immediate: false, // 数据请求逻辑 getData: fetchData, }); const Cmpt: React.FC = (props) => { const [{ data }, { onLoad }] = useFetch(props); return ( {/* ... 略 */} {data} {/* 用户自行触发数据请求 */} onLoad({ id: "a" })}>A onLoad({ id: "b" })}>B ); }; export default Cmpt;

如果仔细看数据Hook的实现,应该会发现,跟「onLoad」同时导出了个「onRefresh」操作接口,它的作用就如其名——「刷新请求」。

数据Hook每传入一个请求参数都会作为最终的请求参数缓存起来,因此想复用上一次请求的参数重新发起数据请求就可以使用「onRefresh」,示例代码如下:

// ... 略 const [{ data }, { onRefresh }] = useFetch(props); return ( {/* ... 略 */} {data} {/* 重新触发数据请求 */} Refresh ); // ... 略 1.3 监听组件属性变化

存在这么一种业务组件,它的某种属性变化,需要将该属性作为请求参数重新发起请求并将请求结果进行呈现。

用过Vue开发的读者们肯定对其「watch」能力喜爱有加,React同样可以做到「watch」。

严格上来说,React并非直接「watch」,而是「props」或「state」的变化会触发「update」,我们通过「新的props或state」与「旧的props或state」进行比对判断某个属性或状态是否变化,从而执行某个动作,实现watch的效果。

因此,对于这种场景,「useFetch」的使用示例代码如下:

import React from "react"; type IProps = { id: string; }; const useFetch = buildUseFetch({ // 设定监听的属性名,被监听的属性值变化会触发重新请求 relation: ["id"], // 使用属性作为请求参数 getData: (_, props) => fetchData(props.id), }); const Cmpt: React.FC = (props) => { const [{ data }] = useFetch(props); return {data}; }; export default Cmpt; 1.4 小结

「useFetch」在实现「请求」上应用了「防抖」,默认防抖间隔为200毫秒,使用者也可以自行定义。

这里列举了三个较为常见的但覆盖了绝大多数实际业务的数据请求的场景,更多能力读者可自行拷贝源码进行探索哈~

2. 列表数据请求

列表数据请求是数据请求细分出的一类常见的请求场景,页面流程与普通数据请求流程相似。它与普通请求的不同点在于列表请求不仅在mounted阶段请求,例如:

提供列表相关分页属性(当前分页页码、总页数和是否有下一页等) 提供列表相关操作接口(刷新和下一页等) 关联查询条件(查询条件的变更会触发重新请求)

示例代码如下:

import React, { useEffect, useState } from "react"; import { Table, Input } from "@arco-design/web-react"; const Cmpt: React.FC = () => { // 初始化列表相关状态 const [data, setData] = useState([]); const [query, setQuery] = useState({ pageNo: 1, pageSize: 10, }); const [total, setTotal] = useState(0); const [loading, setLoading] = useState(false); // 列表数据请求方法 const fetchData = (query: Record) => { setQuery(query); setLoading(true); fetchData(query) .then((res) => { setData(res.data); setTotal(res.total); }) .finally(() => setLoading(false)); }; // mounted发起请求初始化列表数据 useEffect(() => { fetchData(query); }, []); return ( { // 手动触发重新请求 const newQuery = { ...query, keyword: v }; fetchData(newQuery); }} /> { // 手动触发重新请求 fetchData({ pageNo: current, pageSize: size, }); }, }} /> ); }; export default Cmpt;

跟优化普通数据请求方法一致,我们可以可以将这段数据请求与状态更新逻辑抽离成一个公共的列表请求Hook,实现如下:

import { useState, useRef, useEffect, useMemo, useCallback } from 'react'; import { debounce, omit } from 'lodash'; import { useQueue } from './use-queue'; import { useUnmounted } from './use-unmounted'; /** 平台标识 */ export enum EListPlatform { /** 桌面端 */ 'Desktop' = 'Desktop', /** 移动端 */ 'Mobile' = 'Mobile', } export type IUseListData = Record & { /** 数据 */ data: Array; /** 数据总量 */ totalSize?: number; /** 是否更多 */ hasMore?: boolean; }; export type IUseListQuery = { /** 分页页码 */ pageNo: number; /** 分页大小 */ pageSize: number; }; export interface IBuildUseListOptions { /** 平台标识,默认值为Desktop */ platform?: EListPlatform; /** 是否立即查询,默认值为true */ immediate?: boolean; /** 防抖间隔(毫秒),默认值为300 */ duration?: number; /** 关联属性,默认值为空数组 */ relation?: Array; /** 筛选条件,默认值为空数组 */ properties?: Array; /** 筛选条件转换钩子函数 */ getQuery?: (query: any, props: P) => any; /** 加载数据钩子函数 */ getData?: (query: any, props: P) => Promise; } /** * 列表请求Hook工厂函数 * @param options 列表请求Hook配置 * @returns */ export function buildUseList(options: IBuildUseListOptions) { const { // platform = EListPlatform.Desktop, immediate = true, duration = 200, relation = [], properties = [], getQuery = query => query, getData = () => Promise.resolve({ data: [], totalSize: 0, hasMore: false }), } = options; /** * 列表请求Hook * @param props 组件Props * @param _defaultQuery 默认查询条件 * @returns */ function useList( props: P, _defaultQuery?: C & Partial ) { const [inited, setInited] = useState(false); const [loading, setLoading] = useState(immediate); const [loadingMore, setLoadingMore] = useState(false); const defaultQuery = useMemo( () => ({ pageNo: 1, pageSize: 10, ..._defaultQuery, }), [] ); const [pageNo, setPageNo] = useState(defaultQuery.pageNo); const [pageSize, setPageSize] = useState(defaultQuery.pageSize); const [totalSize, setTotalSize] = useState(0); const [list, setList] = useState([]); const [data, setData] = useState({ data: list, totalSize: totalSize, }); const [query, setQuery] = useState({ ...properties.reduce((p, c) => { if (typeof c === 'object') { p[c.key] = c.value; } else { p[c] = undefined; } return p; }, {} as any), ...omit(defaultQuery, ['pageNo', 'pageSize']), }); const [targetQuery, setTargetQuery] = useState(query); // 是否允许加载更多 const [hasMore, setHasMore] = useState(false); // 错误信息 const [error, setError] = useState(); // 缓存变量 const ref = useRef({ props, pageNo, pageSize, totalSize, loading, loadingMore, hasMore, query, targetQuery, data, list, inited, error, }); // 挂载状态 const [, runWithoutUnmounted] = useUnmounted(); // 请求队列 const [, runWithQueue] = useQueue(); // 加载中状态变更方法 const changeLoading = useCallback((active?: boolean, more?: boolean) => { if (active) { ref.current.loading = true; setLoading(true); if (more) { setLoadingMore(true); ref.current.loadingMore = true; } } else { ref.current.loading = false; setLoading(false); ref.current.loadingMore = false; setLoadingMore(false); } }, []); // 数据请求方法 const onFetch = useCallback( debounce(_query => { const fetch = async () => { try { const result = await getData(_query, ref.current.props); ref.current.data = result; runWithoutUnmounted(() => setData(result)); const { data = [], totalSize: _totalSize = 0, hasMore: _hasMore, } = result; ref.current.totalSize = _totalSize; runWithoutUnmounted(() => setTotalSize(_totalSize)); let _list: Array = []; if (ref.current.loadingMore) { _list = ref.current.list.concat(data); } else { _list = data; } ref.current.list = _list; runWithoutUnmounted(() => setList(_list)); ref.current.hasMore = _hasMore ?? (ref.current.pageSize * ref.current.pageNo < totalSize && _list.length < _totalSize); runWithoutUnmounted(() => setHasMore(ref.current.hasMore)); ref.current.error = undefined; } catch (err) { ref.current.error = err; } finally { runWithoutUnmounted(() => setError(ref.current.error)); } }; runWithQueue(fetch, () => { runWithoutUnmounted(() => changeLoading(false)); }); }, duration), [] ); // 数据加载方法 const onLoad = useCallback((_query, _options?: { more?: boolean }) => { changeLoading(true, _options?.more); // 通过筛选条件转换钩子函数获取转换后的请求条件 const newQuery = getQuery( { pageNo: ref.current.pageNo, pageSize: ref.current.pageSize, ..._query, }, ref.current.props ); ref.current.targetQuery = newQuery; setTargetQuery(newQuery); onFetch(newQuery); }, []); // 数据刷新方法 const onRefresh = useCallback((reload?: boolean) => { const _reload = typeof reload === 'boolean' ? reload : false; if (_reload) { ref.current.pageNo = 1; setPageNo(1); } onLoad(ref.current.query); }, []); // 数据加载更多方法 const onLoadMore = useCallback( debounce(() => { changeLoading(true, true); ref.current.pageNo++; setPageNo(ref.current.pageNo); }, duration), [] ); // 组件更新时监测页码变更,若变更自动执行数据加载方法 useEffect(() => { if (!ref.current.inited) { return; } ref.current.pageNo = pageNo; onLoad(ref.current.query); }, [pageNo]); // 组件更新时监测页数和查询参数变更,若变更自动执行数据加载方法 useEffect(() => { if (!ref.current.inited) { return; } ref.current.pageSize = pageSize; ref.current.query = query; onRefresh(true); }, [pageSize, query]); // 组件更新时监测组件Props参数变更(通过关联属性过滤),若变更自动执行数据加载方法 useEffect(() => { if (!ref.current.inited) { return; } const oldProps = ref.current.props; ref.current.props = props; if (relation.find(p => (oldProps as any)[p] !== (props as any)[p])) { onRefresh(true); } }, [props]); // 组件初始化时判断是否自动执行数据加载方法 useEffect(() => { setInited(true); ref.current.inited = true; if (immediate) { onLoad(ref.current.query); } }, []); const listResult = { inited, loading, loadingMore, pageNo, pageSize, totalSize, hasMore, query, targetQuery, list, data, error, }; const listAction = { setInited, setLoading, setLoadingMore, setPageNo, setPageSize, setTotalSize, setQuery, setTargetQuery, setList, setData, setError, onLoad, onRefresh, onLoadMore, }; const ret = [listResult, listAction] as const; return ret; } return useList; }

为演示后续的场景这里同样直接放完善的Hook,该源码已上传至Github中,有兴许的同学们可以康康哈。地址在这里:github.com/pwcong/fron…

我们可以用这个列表请求Hook来优化之前繁冗的代码,优化后结果如下:

import React from "react"; import { Table, Input } from "@arco-design/web-react"; const useList = buildUseList({ getData: fetchData, }); const Cmpt: React.FC = (props) => { // 初始化列表相关状态 const [ { loading, query, list, pageNo, pageSize, total }, { setPageNo, setPageSize, setQuery }, ] = useList(props); return ( { // 查询条件的变更会自动触发重新请求 const newQuery = { ...query, keyword: v }; setQuery(newQuery); }} /> { // 查询条件的变更会自动触发重新请求 setPageNo(current); setPageSize(size); }, }} /> ); }; export default Cmpt;

「useList」的实现参考了「useFetch」并对其进行应对「列表场景」的适配,因此在能力上面是相同的,只多了列表相关的能力。

其中最大的特点是,「useList」关联查询条件,查询条件的变更会触发重新请求,这里的查询条件包含:pageNo、pageSize、query。

因此setPageNo、setPageSize、setQuery都会触发数据请求。

2.1 列表刷新请求

业务场景中存在数据操作或列表操作的场景:

数据操作有「编辑」数据等,这类操作成功后需要刷新列表,页码不变; 一般列表操作有「新增」数据等,这类操作成功后需要刷新列表,并将页码设置为首页;

「useList」提供了「onRefresh」操作接口,其接收一个「boolean」类型的参数(默认值为「true」),若为「true」则「将页码设置为首页并发起请求」,否则「只发起请求」,示例代码如下:

import React from "react"; import { Button } from "@arco-design/web-react"; const useList = buildUseList({ getData: fetchData, }); const Cmpt: React.FC = (props) => { // 初始化列表相关状态 const [, { onRefresh }] = useList(props); return ( {/** ... 略 */} onRefresh(true)}>刷新 ); }; export default Cmpt; 2.2 移动端列表请求

移动端列表相比桌面端列表而言多了个场景,就是列表数据请求为「加载更多」而非「下一页」。

桌面端也有「加载更多」的需求,但是较为少见。

要注意的是,这里的「列表数据」在「加载更多」的动作下请求参数的页码是递增的,列表数据是不断拼接的,而非直接替换原有的列表数据。

「useList」在「build」阶段可配置「platform」参数(默认为Desktop)为「Mobile」来支持这种场景,示例代码如下:

import React from "react"; import { Button } from "@arco-design/web-react"; const useList = buildUseList({ // 配置应用场景为移动端 platform: EListPlatform.Mobile, // 数据请求逻辑 getData: fetchData, }); const Cmpt: React.FC = (props) => { // 初始化列表相关状态 const [{ list, pageNo }, { onLoadMore }] = useList(props); return ( {/** ... 略 */} {list} {/** 执行「加载更多」函数 */} onLoadMore()}>当前页码:{pageNo},加载更多 ); }; export default Cmpt; 最后

组件数据请求是业务系统中极为重要的一个场景,因此我讲它列入「React通用解决方案」专栏中,上述的思考与方案作者本人也应用在实际项目中稳定运行。

当然本篇仅作为作者本人的理解,如果错误之处还望各位大佬们指出或提供更好的意见参考修改哈~



【本文地址】

公司简介

联系我们

今日新闻

    推荐新闻

    专题文章
      CopyRight 2018-2019 实验室设备网 版权所有